/* @flow */

import { queueWatcher } from './scheduler';
import Dep, { pushTarget, popTarget } from './dep';
import { parsePath, isObject, remove, Set, ISet, def } from "./utils";
import { Observer } from './Observer';

let uid = 0;

/**
 * A watcher parses an expression, collects dependencies,
 * and fires callback when the expression value changes.
 * This is used for both the $watch() api and directives.
 */
const nonGetter = function () { };

export default class Watcher<T = any> {

    vm: any;
    cb: (newValue, oldValue) => any;
    id: number;
    deep: boolean;
    lazy: boolean;
    sync: boolean;
    dirty: boolean;
    active: boolean;
    deps: Array<Dep>;
    newDeps: Array<Dep>;
    depIds: ISet;
    newDepIds: ISet;
    getter: (args?) => T;
    value: any;

    constructor(vm: any, expOrFn: string | (() => T), cb: ((newValue: T, oldValue: T) => any), options?: {
        deep?: boolean,
        lazy?: boolean,
        sync?: boolean,
    }) {

        if (vm === undefined || vm === null) {
            throw new Error('vm can not be undefined or null');
        }

        this.vm = vm;
        if (!vm._watchers) {
            def(vm, '_watchers', []);
        }

        vm._watchers.push(this);

        // options
        if (options) {
            this.deep = !!options.deep;
            this.lazy = !!options.lazy;
            this.sync = !!options.sync;
        } else {
            this.deep = this.lazy = this.sync = false;
        }

        this.cb = cb;
        this.id = ++uid; // uid for batching
        this.active = true;
        this.dirty = this.lazy; // for lazy watchers
        this.deps = [];
        this.newDeps = [];
        this.depIds = new Set();
        this.newDepIds = new Set();

        // parse expression for getter
        if (typeof expOrFn === 'function') {
            this.getter = expOrFn;
        } else {
            this.getter = parsePath(expOrFn);
            if (!this.getter) {
                this.getter = (nonGetter as any);
            }
        }

        this.value = this.lazy ? undefined : this.get();
    }

    /**
     * Evaluate the getter, and re-collect dependencies.
     */
    get() {

        pushTarget(this);
        let value;
        const vm = this.vm;

        try {
            value = this.getter.call(vm, vm);
        } catch (e) {
            console.error(e);
        } finally {
            // "touch" every property so they are all tracked as dependencies for deep
            // watching
            if (this.deep) {
                traverse(value);
            }
            popTarget();
            this.cleanupDeps();
        }

        return value;
    }

    /**
     * Add a dependency to this directive.
     */
    addDep(dep: Dep) {

        const id = dep.id;
        if (!this.newDepIds.has(id)) {
            this.newDepIds.add(id);
            this.newDeps.push(dep);
            if (!this.depIds.has(id)) {
                dep.addSub(this);
            }
        }
    }

    /**
     * Clean up for dependency collection.
     */
    cleanupDeps() {

        let i = this.deps.length;
        while (i--) {
            const dep = this.deps[i];
            if (!this.newDepIds.has(dep.id)) {
                dep.removeSub(this);
            }
        }

        let tmp1 = this.depIds;
        this.depIds = this.newDepIds;
        this.newDepIds = tmp1;
        this.newDepIds.clear();
        let tmp2 = this.deps;
        this.deps = this.newDeps;
        this.newDeps = tmp2;
        this.newDeps.length = 0;
    }

    /**
     * Subscriber interface.
     * Will be called when a dependency changes.
     */
    update() {
        /* istanbul ignore else */
        if (this.lazy) {
            this.dirty = true;
        } else if (this.sync) {
            this.run();
        } else {
            queueWatcher(this);
        }
    }

    /**
     * Scheduler job interface.
     * Will be called by the scheduler.
     */
    run() {
        if (this.active) {
            const value = this.get();

            // Deep watchers and watchers on Object/Arrays should fire even when the value
            // is the same, because the value may have mutated.
            if (value !== this.value || isObject(value) || this.deep) {
                // set new value
                const oldValue = this.value;
                this.value = value;
                this.cb.call(this.vm, value, oldValue);
            }
        }
    }

    /**
     * Evaluate the value of the watcher.
     * This only gets called for lazy watchers.
     */
    evaluate() {
        this.value = this.get();
        this.dirty = false;
    }

    /**
     * Depend on all deps collected by this watcher.
     */
    depend() {
        let i = this.deps.length;
        while (i--) {
            this.deps[i].depend();
        }
    }

    /**
     * Remove self from all dependencies' subscriber list.
     */
    teardown() {
        if (this.active) {
            // remove self from vm's watcher list this is a somewhat expensive operation so
            // we skip it if the vm is being destroyed.
            if (!this.vm._isBeingDestroyed) {
                remove(this.vm._watchers, this);
            }

            let i = this.deps.length;
            while (i--) {
                this.deps[i].removeSub(this);
            }
            this.active = false;
        }
    }
}

/**
 * Recursively traverse an object to evoke all converted
 * getters, so that every nested property inside the object
 * is collected as a "deep" dependency.
 */
const seenObjects = new Set();

function traverse(val: any) {
    seenObjects.clear();
    _traverse(val, seenObjects);
}

function _traverse(obj: any, seen: ISet) {
    let i;
    let keys;
    const isA = Array.isArray(obj);

    if ((!isA && !isObject(obj)) || !Object.isExtensible(obj)) {
        return;
    }

    if (obj.__observer__) {
        const depId = obj.__observer__.dep.id;
        if (seen.has(depId)) {
            return;
        }
        seen.add(depId);
    }

    if (isA) {
        i = obj.length;
        while (i--) {
            _traverse(obj[i], seen);
        }
    } else {
        keys = Object.keys(obj);
        i = keys.length;

        let onlys = null;
        let ignores = null;

        if (Array.isArray(obj[Observer.onlyKey])) {
            onlys = obj[Observer.onlyKey];
        }
        if (Array.isArray(obj[Observer.ignoreKey])) {
            if (obj[Observer.ignoreKey].indexOf(Observer.ignoreKey) === -1) {
                obj[Observer.ignoreKey].push(Observer.ignoreKey);
            }
            ignores = obj[Observer.ignoreKey];
        }

        while (i--) {
            if (onlys && onlys.indexOf(keys[i]) === -1) {
                continue;
            }
            if (ignores && onlys.indexOf(keys[i]) !== -1) {
                continue;
            }
            _traverse(obj[keys[i]], seen);
        }

    }
}
